상태관리 : 내가 Server State를 사용하는 방법
Server State 와 Client State를 분리한 이유
React 컴포넌트는 도큐먼트에 따르면, 관심사 분리 & 단일 책임 원칙(SRP) 에 따라 하나의 역할만 담당하는 것을 지향한다. UI 상태(Client State) 와 서버 상태(Server State) 를 혼합하면 컴포넌트가 불필요하게 복잡해지고, 관리하기 어려워지는 것은 다들 느껴봤을 것이다.
개인적으로 Redux 시절에는 Store에 상태보다 API 호출 및 관련 코드가 많아 Store가 거대한 API 관리소처럼 변하기도 했던 기억이 있다.
로딩상태 동기화 방식을 편하게 해주면서 서버 상태를 분리해주는 React Query, Apollo Client 같은 도구를 사용하게 되었다.
굳이 왜 React-Query 같은 도구 사용하냐면
useEffect + useState를 사용하면 되지 뭐하러 react-query를 사용할까?
아래와 같은 코드로 시작을 하게될텐데, 여기에는 몇가지 문제가 있다.
function Bookmarks() {
const [category, setCategory] = useState('hello');
const [data, setData] = useState([]);
const [error, setError] = useState();
useEffect(() => {
fetch(`${endpoint}/${category}`)
.then((res) => res.json())
.then((d) => setData(d))
.catch((e) => setError(e));
}, [category]);
// Return JSX based on data and error state
}
- Race condition
- category가 변경되어, effect가 다시 실행될 때 레이스 컨디션이 발생할 수 있다. 카테고리를 유저가 계속 바꿨을때, 먼저 간 네트워크 요청이 먼저 온다는 보장이 없기 때문에, 다른 카테고리의 데이터를 볼 수도 있다.
- React-Query는 queryKey 기반으로 응답을 매칭해주기 때문에 그런 일이 발생하지 않는다.
- 필연적인 서버 상태
- 서버 상태는 필연적으로 로딩상태, 에러상태에 대한 처리나, 인자가 변경되었을 때 초기화 처리도 해줘야한다.
- 이것을 React-Query가 쉽게 해준다.
- Strict Mode에서 두 번 호출된다.
- React-Query는 캐시처리 등을 해준다.
이렇듯 데이터페칭은 어렵지 않지만, 비동기 상태 관리가 어렵다. 이런 문제를 리액트 쿼리가 훅 호출 한번으로 해결해주니, 충분히 사용할만한 가치가 있는 것이다. 리액트 쿼리를 사용하면 위 문제를 다 해결해준다.
React-Query
SWR (stale-while-revalidation)
React Query의 주요 캐싱 전략은 'stale-while-revalidate'이며, 이 전략은 기본적으로 활성 쿼리(active query)가 존재하면 캐시된 데이터를 즉시 제공하고, 백그라운드에서 새로운 데이터를 다시 가져와 UI를 업데이트하는 방식입니다. 또한 staleTime과 gcTime (garbage collection time)을 설정하여 데이터의 신선도와 유효 기간을 제어할 수 있습니다.
관련 용어
staleTime:데이터가 '신선한(fresh)' 상태로 간주되는 시간을 설정한다. staleTime이 지나지 않은 데이터는 '오래된(stale)' 데이터로 간주되며, 새로운 요청 시 백그라운드 리페칭이 발생한다 기본값은 $0$이며, 이는 데이터가 요청될 때마다 즉시 백그라운드 리페칭이 발생한다는 의미입니다.gcTime: 가비지 컬렉션(gc) 시간을 설정하여, 쿼리 인스턴스가 비활성화된(in-active) 후 캐시된 데이터가 메모리에서 제거되기까지의 시간을 제어합니다. 기본값은 $5$분입니다.=Query Key: queryKey는 데이터를 식별하고 캐시를 관리하는 중요한 요소입니다. 같은 queryKey를 가진 쿼리는 동일한 캐시 공간을 공유합니다. 데이터를 명확하게 구분하기 위해 쿼리 키를 신중하게 설계해야 합니다
그렇다면 다른 캐싱 전략은 뭐가 있을까 ?
- Cache-First (캐시 우선)
- 핵심: 캐시에 데이터가 있으면 캐시를 바로 사용하고, 없을 때만 서버 요청
- 장점: 빠른 응답, 서버 요청 최소화
- 단점: 데이터가 오래되면 최신 정보 반영 어려움
- React Query 대응: staleTime을 충분히 길게 설정하고, enabled: false로 수동 refetch
- Network-First (네트워크 우선)
- 핵심: 서버에서 항상 최신 데이터를 먼저 요청하고, 실패 시 캐시 사용
- 장점: 항상 최신 데이터 보장
- 단점: 네트워크 지연 발생 시 UX 저하
- React Query 대응: staleTime = 0, 즉시 refetch
- Cache-and-Network (동시 접근)
- 핵심: 캐시 데이터를 즉시 보여주면서 서버에도 요청=
- 장점: SWR과 유사하지만, 서버 요청이 UI 갱신에 강하게 연결됨
- 단점: 네트워크 요청이 많아질 수 있음
- React Query 대응: refetchOnMount: true, refetchOnWindowFocus: true
- Network-Only (서버만)
- 핵심: 항상 서버에서 가져오고 캐시는 사용하지 않음
- 장점: 최신 데이터 보장, 단순
- 단점: UX 느림, 오프라인 대응 불가
React Query를 사용하며 만난 이슈들
-
queryKey 관리 복잡성
규모가 커지면 queryKey가 복잡해진다. 이를 위해 queryKey factory 패턴을 활용해 namespace를 관리하는 방식으로 개선했다.
-
useSuspenseQuery의 throw
Suspense 기반 쿼리는 내부적으로 throw를 사용하기 때문에, 중첩된 구조에서는 하위 컴포넌트의 API 호출이 블록되는 경우가 있다.
-
GraphQL과 React Query의 갭
GraphQL을 사용할 때는 React Query의 캐싱 모델이 REST처럼 느껴질 때가 있다. 정규화된 캐싱을 원하면 Apollo Client가 더 자연스럽다.
-
useInfiniteQuery vs useQuery 충돌
두 훅을 동일한 queryKey로 사용하면 서로의 캐시를 덮어씌운다. 데이터 구조 자체가 다르기 때문에 같은 queryKey를 사용하면 안 된다.
-
enabled 옵션의 타입 안정성
enabled는 훌륭하지만 undefined 타입 인자를 처리할 때 번거로움이 있다. React Query v5.25 이후부터 skipToken을 사용하면서 훨씬 우아하게 해결된다.
import { skipToken, useQuery } from '@tanstack/query'; const useGroup = (id: number | undefined) => { return useQuery({ queryKey: ['group', id], queryFn: id ? () => fetchGroup(id) : skipToken, }); };
Apollo Client
GraphQL 전용 클라이언트로, 서버 상태 관리 도구 중 가장 강력한 캐싱 시스템을 갖추고 있다. 쿼리, 뮤테이션, 서브스크립션을 모두 지원하며, 서버 응답을 정규화하여 캐시를 관리한다.
Apollo의 정규화 캐싱
서버에서 받은 데이터를 그대로 캐싱하는 것이 아니라,
typename + id 형태로 분해하여 엔티티 단위로 저장한다.
mutation 이후 변경된 id의 데이터를 캐시에 반영하면, 그 id를 참조 중인 모든 쿼리가 자동으로 최신 상태를 반영한다.
React Query의 queryKey 기반 캐싱과는 완전히 다른 철학이다. GraphQL 구조와 가장 잘 맞는 방식이며, 대규모 데이터 구조에서도 강력한 정합성을 제공한다.
관련 용어
cacheTime: 캐시 데이터의 메모리 유지 시간. 5분이 기본값이며, 접근이 없으면 해당 시간이 지나고 삭제된다.FetchPolicy:Apollo는 staleTime 대신 fetchPolicy로 캐싱 전략을 정의한다.cache-first: 캐시가 있으면 바로 사용. 서버 요청 최소화.network-only(no-store) : 매번 서버에서 요청. 최신성이 중요할 때.cache-and-network: 캐시를 보여주고 동시에 서버 요청. React Query의 SWR과 유사. SWR과 다른 점은 항상 백그라운드에서 캐시를 갱신한다는 점이다.no-cache: 캐시를 저장하지 않는 것이 아니라, “항상 서버 검증 필요”라는 의미. ETag 또는 Last-Modified 기반 조건부 요청이 수행된다.nextFetchPolicy: 캐시 이후 다음 fetch가 어떤 정책을 따를지 정의한다.
GraphQL 타입 관리
GraphQL을 쓸 때 가장 불편한 부분 중 하나가 Fragment 타입 관리다. 스키마가 바뀔 때마다 수동으로 타입을 갱신하는 것은 실수하기 쉽다. 이 때 apollo-codegen 같은 도구를 사용하면 schema → TypeScript 타입을 자동 생성해주기 때문에, 생산성이 눈에 띄게 좋아진다.